Command Line Interface Challenge

Each software project has a structure and each of them starts with a scaffolding. Scaffolding is a process of creating the initial structure of the project. I will not use any project scaffolding tools in this book and also I do not recommend you to use them. Because, they will hide the underlying concepts and technologies from you. Instead of that, I will create the initial structure of the project by hand and I will explain each step in detail. So, you can understand the underlying concepts and technologies in detail.

# Create a new directory for our project
mkdir tdr

# Change the directory to the project directory
cd tdr

# Initialize the git repository
git init

# Initialize the .gitignore file
curl https://www.toptal.com/developers/gitignore/api/linux,macos,windows,node > .gitignore

# Initialize the package.json file
npm init -y

# Create the src directory
mkdir src
touch src/index.ts

# Create a directory for Github Actions
mkdir .github

# Create a directory for VSCode settings
mkdir .vscode

# Initialize the code formatter
npm i -D prettier
touch .prettierrc

# Initialize the linter
npm i -D eslint
touch .eslintrc

# Install the typescript and tsx (Typescript execute)
npm i -D typescript tsx @types/node

# Initialize the typescript configuration
npx tsc --init

# Create a README.md file
touch README.md

Lets add some scripts to the package.json file.

  "main": "src/index.ts",
  "scripts": {
    "format": "prettier --write .",
    "format:check": "prettier --check .",
    "lint": "eslint .",
    "start": "tsx src/index.ts",
    "dev": "tsx --watch src/index.ts",
  },

Implementing without any structure

Although we have a lot of requirements, we will start with a simple subset of them. For this reason, we will just attach title, description, and done fields to out todo list. Also we will just implement basic BREAD operations such as list, add, done, undone, delete (notice that we do not have generic edit or update operation in addition to single read). We will implement all BREAD operations later.

According to these requirements, we can store all of our todos in a single JSON file and the structure of the file might be like this:

[
  {
    "id": 1,
    "title": "A sample task description",
    "description": "This is a sample task description",
    "done": false
  }
]

The plain is simply like this:

  1. Get command line arguments.
  2. Check the existence of the todo list file. If it does not exist, create it.
  3. Parse the command line arguments and decide which operation to perform.
  4. Read the todo list file and parse it.
  5. Perform the operation.
  6. Write the updated list back to the file. (If it is necessary)

Lets implement the first version of our todo list application:

// File: src/index.ts
import process from 'process';
import fs from 'fs';

// Define what a todo looks like
interface Todo {
  id: string;
  title: string;
  description: string;
  completed: boolean;
}

// process.argv is provided by Node.js and it is an array that contains the command line arguments
// The first element is the path to the Node.js executable
// The second element is the path to the script file
// The third element is the subcommand
// The rest of the elements are the arguments
// Get command line arguments
const [program, script, subcommand, ...args] = process.argv;

// If the todo list file does not exist, create it
if (!fs.existsSync('todos.json')) {
  fs.writeFileSync('todos.json', '[]');
}

switch (subcommand) {
  case 'add': {
    const [title, description] = args;

    // Read todo list file and parse it
    const fileConent = fs.readFileSync('todos.json', 'utf-8');
    const todoList = JSON.parse(fileConent) as Todo[];

    // Generate a new todo
    const id = Math.random().toString(36).substr(2, 5);
    const newTodo: Todo = {
      id: id,
      title,
      description,
      completed: false,
    };

    // Add the new todo to the list
    todoList.push(newTodo);

    // Write the updated list back to the file
    const updatedFileContent = JSON.stringify(todoList, null, 2);
    fs.writeFileSync('todos.json', updatedFileContent);

    console.log('New todo added');
    break;
  }
  case 'list': {
    // Read todo list file and parse it
    const fileConent = fs.readFileSync('todos.json', 'utf-8');
    const todoList = JSON.parse(fileConent) as Todo[];

    // Print the list of todos
    for (const todo of todoList) {
      const status = todo.completed ? 'X' : ' ';
      console.log(`- [${status}] (id: ${todo.id}) ${todo.title}`);
      if (todo.description) console.log(`\t${todo.description}`);
    }

    break;
  }
  case 'done': {
    const [id] = args;

    // Read todo list file and parse it
    const fileConent = fs.readFileSync('todos.json', 'utf-8');
    const todoList = JSON.parse(fileConent) as Todo[];

    // Find the todo with the given id
    const todo = todoList.find((todo) => todo.id === args[0]);
    if (!todo) {
      console.log('Todo not found');
      process.exit(1);
    }

    // Mark the todo as completed
    todo.completed = true;

    // Write the updated list back to the file
    const updatedFileContent = JSON.stringify(todoList, null, 2);
    fs.writeFileSync('todos.json', updatedFileContent);

    // Print the message
    console.log('Todo marked as done');

    break;
  }
  case 'undone': {
    const [id] = args;

    // Read todo list file and parse it
    const fileConent = fs.readFileSync('todos.json', 'utf-8');
    const todoList = JSON.parse(fileConent) as Todo[];

    // Find the todo with the given id
    const todo = todoList.find((todo) => todo.id === args[0]);
    if (!todo) {
      console.log('Todo not found');
      process.exit(1);
    }

    // Mark the todo as not completed
    todo.completed = false;

    // Write the updated list back to the file
    const updatedFileContent = JSON.stringify(todoList, null, 2);
    fs.writeFileSync('todos.json', updatedFileContent);

    // Print the message
    console.log('Todo marked as undone');

    break;
  }
  case 'delete': {
    const [id] = args;

    // Read todo list file and parse it
    const fileConent = fs.readFileSync('todos.json', 'utf-8');
    const todoList = JSON.parse(fileConent) as Todo[];

    // Delete the todo with the given id
    const index = todoList.findIndex((todo) => todo.id === args[0]);
    if (index === -1) {
      console.log('Todo not found');
      process.exit(1);
    }
    todoList.splice(index, 1);

    // Write the updated list back to the file
    const updatedFileContent = JSON.stringify(todoList, null, 2);
    fs.writeFileSync('todos.json', updatedFileContent);

    // Print the message
    console.log('Todo deleted');

    break;
  }
  default:
    // Print help messages
    console.log(`Unknown subcommand`);
    console.log(`Usage: tdr <subcommand> [args]`);
    console.log(`Subcommands: add, list, done, undone, delete`);

    // Exit with an error code
    process.exit(1);
}

And we can test the application by running the following commands:

# Add a new todo
npm run start add "A sample task description" "This is a sample task description"
npm run start add "Another task" "This is another task"

# List the todos
npm run start list

# Mark a todo as done
npm run start done #PutYourTodoIdHere#

# Mark a todo as undone
npm run start undone #PutYourTodoIdHere#

# Delete a todo
npm run start delete #PutYourTodoIdHere#

Right now, we have a "working" todo list application but this is not the best implementation. Lets improve it.

Implementing with functions

In the previous implementation, we did not utilize functions. Without changing the requirements and the file structure of the todo list, lets refactor the code to use functions.

First of all, we will use two different files: index.ts and todo.ts. We will move the todo related functions to the todo.ts file and we will import them in the index.ts file.

// File: src/index.ts
import process from 'process';
import * as todo from './todo';

// Get command line arguments
const [program, script, subcommand, ...args] = process.argv;

switch (subcommand) {
  case 'add': {
    const [title, description] = args;
    todo.addTodo(title, description);
  }
  case 'list': {
    todo.listTodos();
    break;
  }
  case 'done': {
    const [id] = args;
    todo.markTodoAsDone(id);
    break;
  }
  case 'undone': {
    const [id] = args;
    todo.markTodoAsUndone(id);
    break;
  }
  case 'delete': {
    const [id] = args;
    todo.deleteTodo(id);
    break;
  }
  default:
    // Print help messages
    console.log(`Unknown subcommand`);
    console.log(`Usage: tdr <subcommand> [args]`);
    console.log(`Subcommands: add, list, done, undone, delete`);

    // Exit with an error code
    process.exit(1);
}
// File: src/todo.ts
import fs from 'fs';

// Define what a todo looks like
export interface Todo {
  id: string;
  title: string;
  description: string;
  completed: boolean;
}

// Read todo list file and parse it
function readTodos(): Todo[] {
  if (!fs.existsSync('todos.json')) {
    fs.writeFileSync('todos.json', '[]');
  }

  const fileConent = fs.readFileSync('todos.json', 'utf-8');
  const todoList = JSON.parse(fileConent) as Todo[];

  return todoList;
}

// Write the updated list back to the file
function writeTodos(todoList: Todo[]): void {
  const updatedFileContent = JSON.stringify(todoList, null, 2);
  fs.writeFileSync('todos.json', updatedFileContent);
}

export function addTodo(title: string, description: string): void {
  const todoList = readTodos();

  // Generate a new todo
  const id = Math.random().toString(36).substr(2, 5);
  const newTodo: Todo = {
    id: id,
    title,
    description,
    completed: false,
  };

  // Add the new todo to the list
  todoList.push(newTodo);

  writeTodos(todoList);

  console.log('New todo added');
}

export function listTodos() {
  const todoList = readTodos();

  // Print the list of todos
  for (const todo of todoList) {
    const status = todo.completed ? 'X' : ' ';
    console.log(`- [${status}] (id: ${todo.id}) ${todo.title}`);
    if (todo.description) console.log(`\t${todo.description}`);
  }
}

export function markTodoAsDone(id: string) {
  const todoList = readTodos();

  // Find the todo with the given id
  const todo = todoList.find((todo) => todo.id === id);
  if (!todo) {
    console.log('Todo not found');
    process.exit(1);
  }

  // Mark the todo as completed
  todo.completed = true;

  writeTodos(todoList);

  // Print the message
  console.log('Todo marked as done');
}

export function markTodoAsUndone(id: string) {
  const todoList = readTodos();

  // Find the todo with the given id
  const todo = todoList.find((todo) => todo.id === id);
  if (!todo) {
    console.log('Todo not found');
    process.exit(1);
  }

  // Mark the todo as not completed
  todo.completed = false;

  writeTodos(todoList);

  // Print the message
  console.log('Todo marked as undone');
}

export function deleteTodo(id: string) {
  const todoList = readTodos();

  // Delete the todo with the given id
  const index = todoList.findIndex((todo) => todo.id === id);
  if (index === -1) {
    console.log('Todo not found');
    process.exit(1);
  }
  todoList.splice(index, 1);

  writeTodos(todoList);

  // Print the message
  console.log('Todo deleted');
}

Implementing with classes

// File: src/index.ts
import process from 'process';
import TodoService from './TodoService';

// Get command line arguments
const [program, script, subcommand, ...args] = process.argv;

const todoService = new TodoService();

switch (subcommand) {
  case 'add': {
    const [title, description] = args;
    todoService.addTodo(title, description);
  }
  case 'list': {
    todoService.listTodos();
    break;
  }
  case 'done': {
    const [id] = args;
    todoService.markTodoAsDone(id);
    break;
  }
  case 'undone': {
    const [id] = args;
    todoService.markTodoAsUndone(id);
    break;
  }
  case 'delete': {
    const [id] = args;
    todoService.deleteTodo(id);
    break;
  }
  default:
    // Print help messages
    console.log(`Unknown subcommand`);
    console.log(`Usage: tdr <subcommand> [args]`);
    console.log(`Subcommands: add, list, done, undone, delete`);

    // Exit with an error code
    process.exit(1);
}
// File: src/TodoService.ts
import fs from 'fs';

// Define what a todo looks like
export interface Todo {
  id: string;
  title: string;
  description: string;
  completed: boolean;
}

// Define the TodoService class
class TodoService {
  constructor() {}

  private readTodos(): Todo[] {
    if (!fs.existsSync('todos.json')) {
      fs.writeFileSync('todos.json', '[]');
    }

    const fileConent = fs.readFileSync('todos.json', 'utf-8');
    const todoList = JSON.parse(fileConent) as Todo[];

    return todoList;
  }

  private writeTodos(todoList: Todo[]): void {
    const updatedFileContent = JSON.stringify(todoList, null, 2);
    fs.writeFileSync('todos.json', updatedFileContent);
  }

  public addTodo(title: string, description: string): void {
    const todoList = this.readTodos();

    // Generate a new todo
    const id = Math.random().toString(36).substr(2, 5);
    const newTodo: Todo = {
      id: id,
      title,
      description,
      completed: false,
    };

    // Add the new todo to the list
    todoList.push(newTodo);

    this.writeTodos(todoList);

    console.log('New todo added');
  }

  public listTodos() {
    const todoList = this.readTodos();

    // Print the list of todos
    for (const todo of todoList) {
      const status = todo.completed ? 'X' : ' ';
      console.log(`- [${status}] (id: ${todo.id}) ${todo.title}`);
      if (todo.description) console.log(`\t${todo.description}`);
    }
  }

  public markTodoAsDone(id: string) {
    const todoList = this.readTodos();

    // Find the todo with the given id
    const todo = todoList.find((todo) => todo.id === id);
    if (!todo) {
      console.log('Todo not found');
      process.exit(1);
    }

    // Mark the todo as completed
    todo.completed = true;

    this.writeTodos(todoList);

    // Print the message
    console.log('Todo marked as done');
  }

  public markTodoAsUndone(id: string) {
    const todoList = this.readTodos();

    // Find the todo with the given id
    const todo = todoList.find((todo) => todo.id === id);
    if (!todo) {
      console.log('Todo not found');
      process.exit(1);
    }

    // Mark the todo as not completed
    todo.completed = false;

    this.writeTodos(todoList);

    // Print the message
    console.log('Todo marked as undone');
  }

  public deleteTodo(id: string) {
    const todoList = this.readTodos();

    // Delete the todo with the given id
    const index = todoList.findIndex((todo) => todo.id === id);
    if (index === -1) {
      console.log('Todo not found');
      process.exit(1);
    }
    todoList.splice(index, 1);

    this.writeTodos(todoList);

    // Print the message
    console.log('Todo deleted');
  }
}

export default TodoService;

Implementing Promise based file operations

// File: src/index.ts
import process from 'process';
import TodoService from './TodoService';

// Get command line arguments
const [program, script, subcommand, ...args] = process.argv;

const todoService = new TodoService();

async function main() {
  await todoService.init();

  switch (subcommand) {
    case 'add': {
      const [title, description] = args;
      await todoService.addTodo(title, description);
    }
    case 'list': {
      await todoService.listTodos();
      break;
    }
    case 'done': {
      const [id] = args;
      await todoService.markTodoAsDone(id);
      break;
    }
    case 'undone': {
      const [id] = args;
      await todoService.markTodoAsUndone(id);
      break;
    }
    case 'delete': {
      const [id] = args;
      await todoService.deleteTodo(id);
      break;
    }
    default:
      // Print help messages
      console.log(`Unknown subcommand`);
      console.log(`Usage: tdr <subcommand> [args]`);
      console.log(`Subcommands: add, list, done, undone, delete`);

      // Exit with an error code
      process.exit(1);
  }
}

main();
// File: src/TodoService.ts
import fs from 'fs';

// Define what a todo looks like
export interface Todo {
  id: string;
  title: string;
  description: string;
  completed: boolean;
}

// Define the TodoService class
class TodoService {
  fileName = 'todos.json';

  constructor() {}

  private async fileExists(): Promise<boolean> {
    try {
      await fs.promises.stat(this.fileName);
      return true;
    } catch {
      return false;
    }
  }

  async init(): Promise<void> {
    if (!(await this.fileExists())) fs.promises.writeFile(this.fileName, '[]');
  }

  private async readTodos(): Promise<Todo[]> {
    const fileConent = await fs.promises.readFile(this.fileName, 'utf-8');
    const todoList = JSON.parse(fileConent) as Todo[];

    return todoList;
  }

  private async writeTodos(todoList: Todo[]): Promise<void> {
    const updatedFileContent = JSON.stringify(todoList, null, 2);
    await fs.promises.writeFile(this.fileName, updatedFileContent);
  }

  public async addTodo(title: string, description: string): Promise<void> {
    const todoList = await this.readTodos();

    // Generate a new todo
    const id = Math.random().toString(36).substr(2, 5);
    const newTodo: Todo = {
      id: id,
      title,
      description,
      completed: false,
    };

    // Add the new todo to the list
    todoList.push(newTodo);

    this.writeTodos(todoList);

    console.log('New todo added');
  }

  public async listTodos(): Promise<void> {
    const todoList = await this.readTodos();

    // Print the list of todos
    for (const todo of todoList) {
      const status = todo.completed ? 'X' : ' ';
      console.log(`- [${status}] (id: ${todo.id}) ${todo.title}`);
      if (todo.description) console.log(`\t${todo.description}`);
    }
  }

  public async markTodoAsDone(id: string): Promise<void> {
    const todoList = await this.readTodos();

    // Find the todo with the given id
    const todo = todoList.find((todo) => todo.id === id);
    if (!todo) {
      console.log('Todo not found');
      process.exit(1);
    }

    // Mark the todo as completed
    todo.completed = true;

    this.writeTodos(todoList);

    // Print the message
    console.log('Todo marked as done');
  }

  public async markTodoAsUndone(id: string): Promise<void> {
    const todoList = await this.readTodos();

    // Find the todo with the given id
    const todo = todoList.find((todo) => todo.id === id);
    if (!todo) {
      console.log('Todo not found');
      process.exit(1);
    }

    // Mark the todo as not completed
    todo.completed = false;

    this.writeTodos(todoList);

    // Print the message
    console.log('Todo marked as undone');
  }

  public async deleteTodo(id: string): Promise<void> {
    const todoList = await this.readTodos();

    // Delete the todo with the given id
    const index = todoList.findIndex((todo) => todo.id === id);
    if (index === -1) {
      console.log('Todo not found');
      process.exit(1);
    }
    todoList.splice(index, 1);

    this.writeTodos(todoList);

    // Print the message
    console.log('Todo deleted');
  }
}

export default TodoService;

Summary

Exercises